// This Source Code Form is subject to the terms of the Mozilla Public License 2.0 at https://mozilla.org/MPL/2.0/
// © Auction Foundry
//@version=6
indicator("Volume Pressure & Divergence [AFD]", "VPD [AFD]", overlay = true, precision = 2, max_bars_back = 500, max_lines_count = 100, max_labels_count = 100, dynamic_requests = true)

// Constants
const int    REQUEST_BARS = 100000
const int    SETS_LIMIT   = 20
const int    BAND_LENGTH  = 20
const int    MARK_ATR_LENGTH = 14
const int    GAUGE_LOOKBACK  = 100
const float  RESUM_RATIO     = 0.001
const float  PRESSURE_TIE    = 0.000001
const color  NEUTRAL      = #787B86
const string ESTIMATE    = "Volume estimated from price moves."
const string CONDITION   = "Not a forecast."
const string GRP_CALC    = "Calculation"
const string GRP_SHOW    = "Show"
const string GRP_COLOR   = "Colors"
const string GRP_GAUGE   = "Right-side gauges"

// Inputs
int pressureLengthInput = input.int(14, "Pressure length", minval = 2, maxval = 100, group = GRP_CALC, display = display.none, tooltip = "• 𝗔𝗩𝗚: candles included in the pressure reading.\n• Higher-volume candles carry more weight.\n• Applies to AVG, band color and divergence.")
int pivotStrengthInput  = input.int(3, "Pivot strength", minval = 1, maxval = 10, group = GRP_CALC, display = display.none, tooltip = "• Candles required on each side of a swing.\n• Higher values identify broader swings.\n• Marks appear after the right-side candles close.")
int maxSwingInput       = input.int(60, "Max swing distance", minval = 10, maxval = 200, group = GRP_CALC, display = display.none, tooltip = "• Maximum gap between two compared swings.\n• Measured in chart candles.\n• The gap must exceed Pivot strength.")
int confirmWindowInput  = input.int(20, "Confirmation window", minval = 1, maxval = 100, group = GRP_CALC, display = display.none, tooltip = "• Candles after detection allowed for confirmation.\n• Needs a later closing break with volume.\n• Price, newer setups or missing data can end it.")
bool bandsInput         = input.bool(true, "Pressure bands around price", group = GRP_SHOW, display = display.none, tooltip = "• 𝗢𝘂𝘁𝗹𝗶𝗻𝗲: AVG pressure.\n• 𝗙𝗶𝗹𝗹: pressure tint; stronger if pending or confirmed.\n• 𝗕𝗼𝘂𝗻𝗱𝘀: 20-candle mean ± 2 price deviations.")
bool priceMarksInput    = input.bool(true, "Price event marks", group = GRP_SHOW, display = display.none, tooltip = "• Labels mark newly detected divergence.\n• Diamonds mark later price confirmation.\n• Hiding marks keeps calculations and alerts active.")
bool levelsInput        = input.bool(true, "Confirmation levels", group = GRP_SHOW, display = display.none, tooltip = "• Pending level: needs a later closing break.\n• Stops extending when the setup ends.\n• Hover price needs Price event marks On.")
color bullColorInput    = input.color(#089981, "Bullish color", inline = "colors", group = GRP_COLOR, display = display.none, tooltip = "• 𝗕𝘂𝗹𝗹𝗶𝘀𝗵: positive pressure and bullish events.\n• 𝗕𝗲𝗮𝗿𝗶𝘀𝗵: negative pressure and bearish events.\n• 𝗚𝗿𝗲𝘆: neutral readings or levels ended without confirmation.")
color bearColorInput    = input.color(#F23645, "Bearish color", inline = "colors", group = GRP_COLOR, display = display.none, tooltip = "• 𝗕𝘂𝗹𝗹𝗶𝘀𝗵: positive pressure and bullish events.\n• 𝗕𝗲𝗮𝗿𝗶𝘀𝗵: negative pressure and bearish events.\n• 𝗚𝗿𝗲𝘆: neutral readings or levels ended without confirmation.")

string gaugeSizeInput = input.string("Normal", "Gauge size", options = ["Compact", "Normal", "Large"], group = GRP_GAUGE, display = display.none, tooltip = "• Changes dial and text size.\n• Chart zoom still affects proportions.")
float gaugeHeightInput = input.float(1.8, "Dial height ratio", minval = 0.8, maxval = 2.0, step = 0.1, group = GRP_GAUGE, display = display.none, tooltip = "• Adjusts dial height for your chart.\n• Can expand the visible price range.")
int gaugeRightGapInput = input.int(12, "Right gap (bars)", minval = 8, maxval = 100, group = GRP_GAUGE, display = display.none, tooltip = "• Moves dials beyond the latest candle.\n• Measured in chart candles.\n• Dials need blank space right of the latest candle.")
float gaugeVerticalGapInput = input.float(4.0, "Vertical gap (% of recent range)", minval = 0.0, maxval = 6.0, step = 1.0, group = GRP_GAUGE, display = display.none, tooltip = "• Adds space between the two dials.\n• Spacing follows the recent 100-candle range.\n• Pressure readings stay unchanged.")

// Event marks sit one ATR beyond price.
float markOffset = math.max(nz(ta.atr(MARK_ATR_LENGTH), high - low), syminfo.mintick)

// Types
type SignalSet
    int side = 0
    int detected = na
    float extreme = na
    float trigger = na
    string status = "Pending"
    line levelLine = na
    label priceDivLabel = na
    label priceConfirmLabel = na
    line priceDivLeader = na
    line priceConfirmLeader = na

// Functions
gaugePrice(float coordinate, float centerPrice, float priceUnit) =>
    centerPrice + (coordinate - 60) * priceUnit

sideColor(int side) =>
    side > 0 ? bullColorInput : side < 0 ? bearColorInput : NEUTRAL

// Preserve color-picker transparency while fading.
fadeColor(color baseColor, float transparency) =>
    color.new(baseColor, color.t(baseColor) + (100 - color.t(baseColor)) * transparency / 100)

// Print levels with the tick's own decimals; `format.mintick` drops digits on fractional ticks.
tickDecimals() =>
    int decimals = 0
    float scaled = syminfo.mintick
    while decimals < 10 and math.abs(scaled - math.round(scaled)) > scaled * 1e-6
        scaled *= 10
        decimals += 1
    decimals

var int priceDecimals = tickDecimals()
var string priceFormat = priceDecimals == 0 ? "0" : "0." + str.repeat("0", priceDecimals)

eventTip(SignalSet item) =>
    string direction = item.side == 1 ? "𝗕𝘂𝗹𝗹𝗶𝘀𝗵" : "𝗕𝗲𝗮𝗿𝗶𝘀𝗵"
    string relation = item.side == 1 ? "price lower low; estimated AVG higher low." : "price higher high; estimated AVG lower high."
    string breakText = (item.side == 1 ? "above " : "below ") + str.tostring(item.trigger, priceFormat)
    string stateText = item.status == "Pending" ? "Pending · later close " + breakText :
       item.status == "Confirmed" ? "Confirmed · closed " + breakText :
       item.status == "Expired" ? "Expired · no confirmation in " + str.tostring(confirmWindowInput) + " candles" :
       item.status == "Invalidated" ? "Invalidated · closed beyond second swing" :
       item.status == "Superseded" ? "Superseded · newer setup replaced it" :
       item.status == "Data gap" ? "Ended · AVG pressure unavailable" :
       item.status == "Already crossed" ? "Already crossed before detection · unarmed" :
       item.status == "Ambiguous" ? "Ambiguous · opposing events, no level" : item.status
    string delayText = "Detected " + str.tostring(pivotStrengthInput) + (pivotStrengthInput == 1 ? " candle" : " candles") + " after the swing. " + CONDITION
    "• " + direction + ": " + relation + "\n• 𝗦𝘁𝗮𝘁𝗲: " + stateText + ".\n• 𝗗𝗲𝗹𝗮𝘆: " + delayText

finishSet(SignalSet item, string terminal) =>
    item.status := terminal
    color retiredColor = terminal == "Confirmed" ? sideColor(item.side) : NEUTRAL
    if not na(item.levelLine)
        line.set_x2(item.levelLine, time_close)
        line.set_color(item.levelLine, retiredColor)
        line.set_style(item.levelLine, terminal == "Confirmed" ? line.style_solid : line.style_dotted)
    if not na(item.priceDivLabel)
        label.set_tooltip(item.priceDivLabel, eventTip(item))
    item

destroySet(SignalSet item) =>
    if not na(item.levelLine)
        line.delete(item.levelLine)
    if not na(item.priceDivLabel)
        label.delete(item.priceDivLabel)
    if not na(item.priceConfirmLabel)
        label.delete(item.priceConfirmLabel)
    if not na(item.priceDivLeader)
        line.delete(item.priceDivLeader)
    if not na(item.priceConfirmLeader)
        line.delete(item.priceConfirmLeader)

admitSet(array<SignalSet> setsArray, SignalSet item) =>
    if array.size(setsArray) >= SETS_LIMIT
        // Keep the active setup when trimming old marks.
        int victim = array.get(setsArray, 0).status == "Pending" ? 1 : 0
        SignalSet removedSet = array.remove(setsArray, victim)
        destroySet(removedSet)
    array.push(setsArray, item)

drawDivergence(SignalSet item) =>
    color eventColor = sideColor(item.side)
    if priceMarksInput
        float labelY = item.extreme - item.side * markOffset
        item.priceDivLabel := label.new(time, labelY, item.side == 1 ? "Bull div" : "Bear div", xloc = xloc.bar_time, style = item.side == 1 ? label.style_label_up : label.style_label_down, color = fadeColor(eventColor, 20), textcolor = chart.fg_color, size = size.tiny, tooltip = eventTip(item), force_overlay = true)
        item.priceDivLeader := line.new(time[pivotStrengthInput], item.extreme, time, labelY, xloc = xloc.bar_time, color = fadeColor(eventColor, 50), style = line.style_dotted, width = 1, force_overlay = true)
    if levelsInput and item.status == "Pending"
        item.levelLine := line.new(time, item.trigger, time_close, item.trigger, xloc = xloc.bar_time, color = eventColor, style = line.style_dashed, force_overlay = true)
    item

drawConfirmation(SignalSet item) =>
    color eventColor = sideColor(item.side)
    if priceMarksInput
        float anchorY = item.side == 1 ? low : high
        float diamondY = anchorY - item.side * markOffset
        item.priceConfirmLabel := label.new(time, diamondY, "", xloc = xloc.bar_time, style = label.style_diamond, color = eventColor, size = size.small, tooltip = eventTip(item), force_overlay = true)
        item.priceConfirmLeader := line.new(time, anchorY, time, diamondY, xloc = xloc.bar_time, color = fadeColor(eventColor, 50), style = line.style_dotted, width = 1, force_overlay = true)
    item

makeSet(int side, int firstIndex, float secondExtreme) =>
    int distance = bar_index - pivotStrengthInput - firstIndex
    float trigger = na
    // Freeze the between-swing high/low; the distance guard keeps this scan nonempty.
    for offset = pivotStrengthInput + 1 to pivotStrengthInput + distance - 1
        float level = side == 1 ? high[offset] : low[offset]
        trigger := na(trigger) ? level : side == 1 ? math.max(trigger, level) : math.min(trigger, level)
    bool alreadyCrossed = false
    for offset = 0 to pivotStrengthInput - 1
        if side * (close[offset] - trigger) > 0
            alreadyCrossed := true
            break
    SignalSet.new(side, bar_index, secondExtreme, trigger, alreadyCrossed ? "Already crossed" : "Pending")

// Lower-timeframe volume supplies the directional estimate.
bool supportedChart = chart.is_standard and timeframe.isminutes and timeframe.multiplier >= 1 and timeframe.multiplier <= 240
string effectiveTimeframe = timeframe.isminutes and timeframe.multiplier == 1 ? "10S" : "1"
// Pine infers tuple types; other declarations are explicit.
array<float> opensArray = array.new<float>()
array<float> closesArray = array.new<float>()
array<float> volumesArray = array.new<float>()
if supportedChart
    [requestedOpensArray, requestedClosesArray, requestedVolumesArray] = request.security_lower_tf(syminfo.tickerid, supportedChart ? effectiveTimeframe : "", [open, close, volume], ignore_invalid_timeframe = true, calc_bars_count = REQUEST_BARS)
    opensArray := requestedOpensArray
    closesArray := requestedClosesArray
    volumesArray := requestedVolumesArray

var array<float> deltaWindowArray = array.new<float>()
var array<float> volumeWindowArray = array.new<float>()
var float previousIntrabarClose = na
var int previousDirection = 0
var bool boundaryNext = true
int intrabarCount = array.size(volumesArray)
bool rowsValid = supportedChart and intrabarCount > 0 and array.size(opensArray) == intrabarCount and array.size(closesArray) == intrabarCount
float deltaValue = 0.0
float totalVolume = 0.0
float lastIntrabarClose = previousIntrabarClose
int lastDirection = previousDirection
if rowsValid
    for index = 0 to intrabarCount - 1
        float intrabarOpen = array.get(opensArray, index)
        float intrabarClose = array.get(closesArray, index)
        float intrabarVolume = array.get(volumesArray, index)
        if na(intrabarOpen) or na(intrabarClose) or na(intrabarVolume) or intrabarVolume < 0
            rowsValid := false
        else
            int direction = intrabarClose > intrabarOpen ? 1 : intrabarClose < intrabarOpen ? -1 : 0
            if direction == 0 and not na(lastIntrabarClose)
                direction := intrabarClose > lastIntrabarClose ? 1 : intrabarClose < lastIntrabarClose ? -1 : 0
            direction := direction == 0 ? lastDirection : direction
            lastIntrabarClose := intrabarClose
            if direction != 0
                lastDirection := direction
            deltaValue += direction * intrabarVolume
            totalVolume += intrabarVolume

bool acceptedBar = rowsValid and not boundaryNext
int windowSize = array.size(deltaWindowArray)
// Running sums; the volume-candle count keeps an all-zero window exactly zero.
// The peak bounds cancellation: a sum far below it is re-added exactly.
var float windowDelta = 0.0
var float windowVolume = 0.0
var float windowPeak = 0.0
var int windowActiveCount = 0
bool windowFull = windowSize == pressureLengthInput
float droppedDelta = windowFull ? array.first(deltaWindowArray) : 0.0
float droppedVolume = windowFull ? array.first(volumeWindowArray) : 0.0
int previewActiveCount = windowActiveCount - (droppedVolume > 0 ? 1 : 0) + (totalVolume > 0 ? 1 : 0)
float previewDelta = previewActiveCount == 0 ? 0.0 : windowDelta - droppedDelta + deltaValue
float previewVolume = previewActiveCount == 0 ? 0.0 : windowVolume - droppedVolume + totalVolume
float previewPeak = math.max(windowPeak, windowVolume + totalVolume)
if previewActiveCount > 0 and previewVolume < previewPeak * RESUM_RATIO
    previewDelta := deltaValue
    previewVolume := totalVolume
    int firstKept = windowFull ? 1 : 0
    if windowSize > firstKept
        for index = firstKept to windowSize - 1
            previewDelta += array.get(deltaWindowArray, index)
            previewVolume += array.get(volumeWindowArray, index)
    previewPeak := previewVolume
int previewCount = acceptedBar ? math.min(windowSize + 1, pressureLengthInput) : 0
float pressureValue = acceptedBar and previewCount == pressureLengthInput and previewVolume > 0 ? math.max(-100, math.min(100, 100 * previewDelta / previewVolume)) : na
float histogramValue = acceptedBar and totalVolume > 0 ? math.max(-100, math.min(100, 100 * deltaValue / totalVolume)) : na

// Recognize swings; equal prices favor the later swing.
float pivotLow = low[pivotStrengthInput]
float pivotHigh = high[pivotStrengthInput]
// Pine's history rollback keeps this count exact on realtime updates.
var int pressureRun = 0
pressureRun := na(pressureValue) ? 0 : pressureRun + 1
float nearLow = ta.lowest(low, pivotStrengthInput)
float nearHigh = ta.highest(high, pivotStrengthInput)
bool lowPivot = not na(pivotLow) and pivotLow < nearLow and pivotLow <= nearLow[pivotStrengthInput + 1]
bool highPivot = not na(pivotHigh) and pivotHigh > nearHigh and pivotHigh >= nearHigh[pivotStrengthInput + 1]
bool pivotWindowValid = pressureRun >= 2 * pivotStrengthInput + 1

var array<SignalSet> retainedSetsArray = array.new<SignalSet>()
var SignalSet pendingSet = na
var int previousLowIndex = na
var int previousHighIndex = na
var float previousLowPrice = na
var float previousHighPrice = na
var float previousLowPressure = na
var float previousHighPressure = na
bool bullDivergence = false
bool bearDivergence = false
bool bullConfirmation = false
bool bearConfirmation = false

if barstate.isconfirmed
    if not rowsValid
        array.clear(deltaWindowArray)
        array.clear(volumeWindowArray)
        windowDelta := 0.0
        windowVolume := 0.0
        windowPeak := 0.0
        windowActiveCount := 0
        previousIntrabarClose := na
        previousDirection := 0
        boundaryNext := true
    else
        previousIntrabarClose := lastIntrabarClose
        previousDirection := lastDirection
        if boundaryNext
            boundaryNext := false
        else
            if windowFull
                array.shift(deltaWindowArray)
                array.shift(volumeWindowArray)
            array.push(deltaWindowArray, deltaValue)
            array.push(volumeWindowArray, totalVolume)
            windowActiveCount := previewActiveCount
            windowDelta := previewDelta
            windowVolume := previewVolume
            windowPeak := previewActiveCount == 0 ? 0.0 : previewPeak

    if na(pressureValue)
        if not na(pendingSet)
            finishSet(pendingSet, "Data gap")
            pendingSet := na
        previousLowIndex := na
        previousHighIndex := na
    else
        // Resolve pending divergence before admitting a new setup.
        if not na(pendingSet)
            if not na(pendingSet.levelLine)
                line.set_x2(pendingSet.levelLine, time_close)
            int age = bar_index - pendingSet.detected
            if pendingSet.side * (close - pendingSet.extreme) < 0
                finishSet(pendingSet, "Invalidated")
                pendingSet := na
            else if age > confirmWindowInput
                finishSet(pendingSet, "Expired")
                pendingSet := na
            else if age >= 1 and totalVolume > 0 and pendingSet.side * (close - pendingSet.trigger) > 0
                bullConfirmation := pendingSet.side == 1
                bearConfirmation := pendingSet.side == -1
                finishSet(pendingSet, "Confirmed")
                drawConfirmation(pendingSet)
                pendingSet := na

        SignalSet newBullSet = na
        SignalSet newBearSet = na
        int pivotIndex = bar_index - pivotStrengthInput
        bool endpointValid = pivotWindowValid and totalVolume[pivotStrengthInput] > 0
        if lowPivot
            if endpointValid
                int distance = na(previousLowIndex) ? 0 : pivotIndex - previousLowIndex
                if totalVolume > 0 and distance >= pivotStrengthInput + 1 and distance <= maxSwingInput and pivotLow < previousLowPrice and pressureValue[pivotStrengthInput] > previousLowPressure + PRESSURE_TIE
                    newBullSet := makeSet(1, previousLowIndex, pivotLow)
                previousLowIndex := pivotIndex
                previousLowPrice := pivotLow
                previousLowPressure := pressureValue[pivotStrengthInput]
            else
                previousLowIndex := na
        if highPivot
            if endpointValid
                int distance = na(previousHighIndex) ? 0 : pivotIndex - previousHighIndex
                if totalVolume > 0 and distance >= pivotStrengthInput + 1 and distance <= maxSwingInput and pivotHigh > previousHighPrice and pressureValue[pivotStrengthInput] < previousHighPressure - PRESSURE_TIE
                    newBearSet := makeSet(-1, previousHighIndex, pivotHigh)
                previousHighIndex := pivotIndex
                previousHighPrice := pivotHigh
                previousHighPressure := pressureValue[pivotStrengthInput]
            else
                previousHighIndex := na

        bullDivergence := not na(newBullSet)
        bearDivergence := not na(newBearSet)
        if bullDivergence and bearDivergence
            newBullSet.status := "Ambiguous"
            newBearSet.status := "Ambiguous"
        else
            SignalSet newSet = bullDivergence ? newBullSet : newBearSet
            if not na(newSet) and newSet.status == "Pending"
                if not na(pendingSet)
                    finishSet(pendingSet, "Superseded")
                pendingSet := newSet
        if bullDivergence
            drawDivergence(newBullSet)
            admitSet(retainedSetsArray, newBullSet)
        if bearDivergence
            drawDivergence(newBearSet)
            admitSet(retainedSetsArray, newBearSet)

// AVG pressure colors the outlines; setup state strengthens and recolors the fill.
float bandMiddle = ta.sma(close, BAND_LENGTH)
float bandHalf = 2.0 * ta.stdev(close, BAND_LENGTH, true)
bool bandReady = bandsInput and not na(pressureValue) and not na(bandHalf)
float bandUpper = bandReady ? bandMiddle + bandHalf : na
float bandLower = bandReady ? bandMiddle - bandHalf : na
float bandCenter = bandReady ? bandMiddle : na
float magnitude = na(pressureValue) ? 0 : math.min(math.abs(pressureValue), 100) / 100
int pressureSide = na(pressureValue) ? 0 : pressureValue > 0 ? 1 : pressureValue < 0 ? -1 : 0
int confirmedSide = bullConfirmation ? 1 : bearConfirmation ? -1 : 0
int pendingSide = na(pendingSet) ? 0 : pendingSet.side
int fillSide = confirmedSide != 0 ? confirmedSide : pendingSide != 0 ? pendingSide : pressureSide
float fillTransparency = confirmedSide != 0 ? 50 : pendingSide != 0 ? 72 : 94 - 10 * magnitude
color edgeColor = fadeColor(sideColor(pressureSide), 75 - 50 * magnitude)
color shadeColor = sideColor(fillSide)
plot(pressureValue, "AVG pressure %", display = display.data_window)
// Pine infers plot ID types.
upperBandPlot = plot(bandUpper, "Upper pressure band", edgeColor, 1, style = plot.style_linebr, force_overlay = true, display = display.pane)
lowerBandPlot = plot(bandLower, "Lower pressure band", edgeColor, 1, style = plot.style_linebr, force_overlay = true, display = display.pane)
middleBandPlot = plot(bandCenter, "Band middle", color(na), force_overlay = true, display = display.none)
fill(upperBandPlot, middleBandPlot, bandUpper, bandCenter, fadeColor(shadeColor, fillTransparency), fadeColor(shadeColor, 100), "Upper pressure shade", fillgaps = false)
fill(middleBandPlot, lowerBandPlot, bandCenter, bandLower, fadeColor(shadeColor, 100), fadeColor(shadeColor, fillTransparency), "Lower pressure shade", fillgaps = false)

plot(histogramValue, "Current pressure %", display = display.data_window)
plot(supportedChart ? intrabarCount : na, "Intrabars returned", display = display.data_window)
plot(bullDivergence ? 1 : 0, "Bullish divergence event", display = display.data_window)
plot(bearDivergence ? 1 : 0, "Bearish divergence event", display = display.data_window)
plot(bullConfirmation ? 1 : 0, "Bullish price confirmation event", display = display.data_window)
plot(bearConfirmation ? 1 : 0, "Bearish price confirmation event", display = display.data_window)
plot(pendingSide, "Pending divergence side", display = display.data_window)
plot(na(pendingSet) ? na : pendingSet.trigger, "Pending confirmation price", display = display.data_window, format = format.price, precision = 16)

// CURRENT uses one candle; AVG weights the latest window by volume.
var array<line> gaugeLinesArray = array.new_line(0)
var array<label> gaugeLabelsArray = array.new_label(0)
if barstate.isfirst
    for gaugeIndex = 0 to 1
        for slot = 0 to 9
            array.push(gaugeLinesArray, line.new(na, na, na, na, style = slot == 9 ? line.style_arrow_right : line.style_solid, width = 2))
        for slot = 0 to 12
            array.push(gaugeLabelsArray, label.new(na, na, "", style = slot == 2 or slot == 3 ? label.style_circle : label.style_none, textalign = text.align_center, text_formatting = slot == 0 or (slot >= 4 and slot % 2 == 0) ? text.format_bold : text.format_none))

float rangeHigh = ta.highest(high, GAUGE_LOOKBACK)
float rangeLow = ta.lowest(low, GAUGE_LOOKBACK)

var label divergenceTitleLabel = label.new(na, na, "", style = label.style_none, textalign = text.align_center, text_formatting = text.format_bold)
var label divergenceStateLabel = label.new(na, na, "", style = label.style_none, textalign = text.align_center, text_formatting = text.format_bold)
var label divergenceDetailLabel = label.new(na, na, "", style = label.style_none, textalign = text.align_center)
if barstate.islast
    bool geometryReady = not na(high) and not na(low)
    float rangeSpan = math.max(nz(rangeHigh, high) - nz(rangeLow, low), syminfo.mintick * 100)
    float rangeMidpoint = (nz(rangeHigh, high) + nz(rangeLow, low)) * 0.5 + rangeSpan * 0.015
    float sizeFactor = gaugeSizeInput == "Compact" ? 0.8 : gaugeSizeInput == "Large" ? 1.25 : 1.0
    int radiusBars = gaugeSizeInput == "Compact" ? 9 : gaugeSizeInput == "Large" ? 15 : 12
    int centerIndex = bar_index + gaugeRightGapInput + math.round(radiusBars * 1.25)
    float priceUnit = rangeSpan * 0.085 * sizeFactor * gaugeHeightInput / 60.0
    float centerSeparation = rangeSpan * (0.34 * sizeFactor * gaugeHeightInput + gaugeVerticalGapInput * 0.01)
    for gaugeIndex = 0 to 1
        float centerPrice = rangeMidpoint + (gaugeIndex == 0 ? 0.5 : -0.5) * centerSeparation
        float value = gaugeIndex == 0 ? histogramValue : pressureValue
        bool valueReady = geometryReady and not na(value)
        color needleColor = na(value) ? NEUTRAL : sideColor(value > 0 ? 1 : value < 0 ? -1 : 0)
        string valueText = na(value) ? "Unavailable" : (value > 0 ? "+" : "") + str.tostring(value, "0.00") + "%"
        string candleStatus = barstate.isconfirmed ? "Latest candle · closed." : "Latest candle · live."
        string gaugeStatus = not supportedChart ? "Standard charts · 1m–4h only." : not rowsValid ? "Intrabar volume missing or invalid." : boundaryNext or not acceptedBar ? "Rebuilding after a data gap." : na(value) ? (gaugeIndex == 0 ? "No volume on latest candle." : previewCount < pressureLengthInput ? "Building the " + str.tostring(pressureLengthInput) + "-candle AVG." : "No volume in AVG window.") : candleStatus
        string gaugeTooltip = gaugeIndex == 0 ? "• 𝗖𝘂𝗿𝗿𝗲𝗻𝘁 𝗽𝗿𝗲𝘀𝘀𝘂𝗿𝗲: " + valueText + "\n• " + gaugeStatus + "\n• 𝗭𝗲𝗿𝗼: neutral estimate.\n• + up pressure · − down pressure.\n• " + ESTIMATE : "• 𝗔𝗩𝗚 " + str.tostring(pressureLengthInput) + " 𝗽𝗿𝗲𝘀𝘀𝘂𝗿𝗲: " + valueText + "\n• Latest " + str.tostring(pressureLengthInput) + " candles.\n• More volume = more weight.\n• " + gaugeStatus + "\n• " + ESTIMATE
        for slot = 0 to 8
            float angle = math.pi * (1.25 - slot * 1.5 / 8.0)
            line tickLine = array.get(gaugeLinesArray, gaugeIndex * 10 + slot)
            line.set_xy1(tickLine, centerIndex + math.round(radiusBars * 0.66 * math.cos(angle)), geometryReady ? gaugePrice(60 + 39.6 * math.sin(angle), centerPrice, priceUnit) : na)
            line.set_xy2(tickLine, centerIndex + math.max(1 - radiusBars, math.min(radiusBars - 1, math.round(radiusBars * 0.80 * math.cos(angle)))), geometryReady ? gaugePrice(60 + 48 * math.sin(angle), centerPrice, priceUnit) : na)
            line.set_color(tickLine, slot == 4 ? color.new(chart.fg_color, 25) : fadeColor(slot < 4 ? bearColorInput : bullColorInput, valueReady ? 20 : 80))
        float needleAngle = math.pi * (0.5 - math.max(-100.0, math.min(100.0, nz(value, 0.0))) * 0.0075)
        line needleLine = array.get(gaugeLinesArray, gaugeIndex * 10 + 9)
        line.set_xy1(needleLine, centerIndex, valueReady ? gaugePrice(60, centerPrice, priceUnit) : na)
        line.set_xy2(needleLine, centerIndex + math.round(radiusBars * 0.63 * math.cos(needleAngle)), valueReady ? gaugePrice(60 + 37.8 * math.sin(needleAngle), centerPrice, priceUnit) : na)
        line.set_color(needleLine, needleColor)
        for slot = 0 to 12
            label gaugeLabel = array.get(gaugeLabelsArray, gaugeIndex * 13 + slot)
            float scaleAngle = math.pi * (1.25 - (slot - 4) * 1.5 / 8.0)
            int labelIndex = slot < 4 ? centerIndex : centerIndex + math.round(radiusBars * 1.12 * math.cos(scaleAngle))
            float labelY = slot == 0 ? 157 : slot == 1 ? -32 : slot == 2 ? 60 : slot == 3 ? 60 : 60 + 67 * math.sin(scaleAngle)
            int scaleValue = (slot - 8) * 25
            string scaleText = (scaleValue > 0 ? "+" : "") + str.tostring(scaleValue)
            string labelText = slot == 0 ? (gaugeIndex == 0 ? "CURRENT PRESSURE" : "AVG " + str.tostring(pressureLengthInput) + " PRESSURE") : slot == 1 ? valueText : slot == 2 or slot == 3 ? "" : scaleText
            label.set_xy(gaugeLabel, labelIndex, geometryReady ? gaugePrice(labelY, centerPrice, priceUnit) : na)
            bool majorScale = slot >= 4 and slot % 2 == 0
            int baseTextSize = slot == 1 ? 18 : slot == 0 ? 12 : majorScale ? 10 : 9
            if slot == 2 or slot == 3
                label.set_size(gaugeLabel, math.max(1, math.round((slot == 2 ? 4 : 1) * sizeFactor)))
            else
                label.set_size(gaugeLabel, math.max(8, math.round(baseTextSize * sizeFactor)))
            label.set_text(gaugeLabel, geometryReady ? labelText : "")
            label.set_color(gaugeLabel, not geometryReady ? na : slot == 2 ? color.new(chart.fg_color, 35) : slot == 3 ? needleColor : na)
            label.set_textcolor(gaugeLabel, slot == 1 or slot == 3 ? needleColor : slot == 2 ? color.new(chart.fg_color, 35) : slot == 0 or majorScale ? chart.fg_color : color.new(chart.fg_color, 25))
            label.set_tooltip(gaugeLabel, gaugeTooltip)
    bool opposingEvents = bullDivergence and bearDivergence
    bool divergenceReady = supportedChart and rowsValid and not na(pressureValue)
    int activeSide = pendingSide != 0 ? pendingSide : bullDivergence and not bearDivergence ? 1 : bearDivergence and not bullDivergence ? -1 : 0
    int statusSide = confirmedSide != 0 ? confirmedSide : activeSide
    string stage = not divergenceReady ? "Unavailable" : opposingEvents ? "Ambiguous divergence" : confirmedSide != 0 ? (confirmedSide == 1 ? "Bull" : "Bear") + " · confirmed" : activeSide != 0 ? (activeSide == 1 ? "Bull" : "Bear") + (pendingSide != 0 ? " · pending" : " · unarmed") : "No pending divergence"
    string dataStatus = not supportedChart ? "Standard charts, 1m–4h only." : not rowsValid ? (effectiveTimeframe == "10S" ? "10s volume data missing or invalid." : "Volume data missing or invalid.") : boundaryNext or not acceptedBar ? "Waiting for a full candle after a data gap." : totalVolume == 0 ? "No volume on latest candle." : na(pressureValue) ? (previewCount < pressureLengthInput ? "Building the " + str.tostring(pressureLengthInput) + "-candle AVG." : "No volume in the AVG window.") : str.format_time(time, "MM-dd HH:mm", syminfo.timezone) + " " + syminfo.timezone + "."
    float avgCenterPrice = rangeMidpoint - centerSeparation * 0.5
    bool pendingShown = pendingSide != 0 and divergenceReady
    // Name the pending side when the state line describes a different event.
    string pendingLead = opposingEvents or confirmedSide != 0 ? (pendingSide == 1 ? "Bull pending · confirms" : "Bear pending · confirms") : "Confirms"
    int candlesLeft = pendingShown ? math.max(0, confirmWindowInput - (bar_index - pendingSet.detected) + (barstate.isconfirmed ? 0 : 1)) : 0
    string detailText = pendingShown ? pendingLead + " on close " + (pendingSide == 1 ? "above " : "below ") + str.tostring(pendingSet.trigger, priceFormat) + "\n" + str.tostring(candlesLeft) + " of " + str.tostring(confirmWindowInput) + " candles left" : not divergenceReady ? dataStatus : activeSide != 0 or opposingEvents ? "No armed level" : ""
    string statusTooltip = "• 𝗦𝘁𝗮𝘁𝘂𝘀: " + stage + ".\n• 𝗗𝗮𝘁𝗮: " + dataStatus + "\n• Estimated pressure · events at candle close."
    label.set_xy(divergenceTitleLabel, centerIndex, gaugePrice(-62, avgCenterPrice, priceUnit))
    label.set_size(divergenceTitleLabel, math.round(12 * sizeFactor))
    label.set_text(divergenceTitleLabel, geometryReady ? "DIVERGENCE" : "")
    label.set_textcolor(divergenceTitleLabel, chart.fg_color)
    label.set_tooltip(divergenceTitleLabel, statusTooltip)
    label.set_xy(divergenceStateLabel, centerIndex, gaugePrice(-88, avgCenterPrice, priceUnit))
    label.set_size(divergenceStateLabel, math.round(14 * sizeFactor))
    label.set_text(divergenceStateLabel, geometryReady ? stage : "")
    label.set_textcolor(divergenceStateLabel, not divergenceReady or opposingEvents or statusSide == 0 ? NEUTRAL : sideColor(statusSide))
    label.set_tooltip(divergenceStateLabel, statusTooltip)
    label.set_xy(divergenceDetailLabel, centerIndex, gaugePrice(-118, avgCenterPrice, priceUnit))
    label.set_size(divergenceDetailLabel, math.max(8, math.round(10 * sizeFactor)))
    label.set_text(divergenceDetailLabel, geometryReady ? detailText : "")
    label.set_textcolor(divergenceDetailLabel, color.new(chart.fg_color, 25))
    label.set_tooltip(divergenceDetailLabel, statusTooltip)

// Alerts
alertcondition(bullDivergence, "Bullish divergence", "VPD bullish divergence detected on {{ticker}} {{interval}} at chart-bar close: lower price low and higher estimated intrabar pressure. Detection follows the second pivot by the configured strength; Ambiguous or Already crossed divergence may not arm a confirmation level. This is a computed condition, not a forecast.")
alertcondition(bearDivergence, "Bearish divergence", "VPD bearish divergence detected on {{ticker}} {{interval}} at chart-bar close: higher price high and lower estimated intrabar pressure. Detection follows the second pivot by the configured strength; Ambiguous or Already crossed divergence may not arm a confirmation level. This is a computed condition, not a forecast.")
alertcondition(bullConfirmation, "Bullish price confirmation", "VPD bullish price confirmation on {{ticker}} {{interval}}: a subsequent chart bar closed above the divergence's frozen intervening high. Pressure uses estimated directional intrabar volume; this is a computed condition, not a forecast.")
alertcondition(bearConfirmation, "Bearish price confirmation", "VPD bearish price confirmation on {{ticker}} {{interval}}: a subsequent chart bar closed below the divergence's frozen intervening low. Pressure uses estimated directional intrabar volume; this is a computed condition, not a forecast.")